//
//  STPPaymentHandler.swift
//  StripeiOS
//
//  Created by Cameron Sabol on 5/10/19.
//  Copyright © 2019 Stripe, Inc. All rights reserved.
//

import Foundation
import PassKit
import SafariServices

#if canImport(Stripe3DS2)
import Stripe3DS2
#endif

/// `STPPaymentHandlerActionStatus` represents the possible outcomes of requesting an action by `STPPaymentHandler`. An action could be confirming and/or handling the next action for a PaymentIntent.
@objc public enum STPPaymentHandlerActionStatus: Int {
  /// The action succeeded.
  case succeeded
  /// The action was cancelled by the cardholder/user.
  case canceled
  /// The action failed. See the error code for more details.
  case failed
}

/// Error codes generated by `STPPaymentHandler`
@objc public enum STPPaymentHandlerErrorCode: Int {
  /// Indicates that the action requires an authentication method not recognized or supported by the SDK.
  @objc(STPPaymentHandlerUnsupportedAuthenticationErrorCode)
  case unsupportedAuthenticationErrorCode

  /// Attach a payment method to the PaymentIntent or SetupIntent before using `STPPaymentHandler`.
  @objc(STPPaymentHandlerRequiresPaymentMethodErrorCode)
  case requiresPaymentMethodErrorCode

  /// The PaymentIntent or SetupIntent status cannot be resolved by `STPPaymentHandler`.
  @objc(STPPaymentHandlerIntentStatusErrorCode)
  case intentStatusErrorCode

  /// The action timed out.
  @objc(STPPaymentHandlerTimedOutErrorCode)
  case timedOutErrorCode

  /// There was an error in the Stripe3DS2 SDK.
  @objc(STPPaymentHandlerStripe3DS2ErrorCode)
  case stripe3DS2ErrorCode

  /// The transaction did not authenticate (e.g. user entered the wrong code).
  @objc(STPPaymentHandlerNotAuthenticatedErrorCode)
  case notAuthenticatedErrorCode

  /// `STPPaymentHandler` does not support concurrent actions.
  @objc(STPPaymentHandlerNoConcurrentActionsErrorCode)
  case noConcurrentActionsErrorCode

  /// Payment requires a valid `STPAuthenticationContext`.  Make sure your presentingViewController isn't already presenting.
  @objc(STPPaymentHandlerRequiresAuthenticationContextErrorCode)
  case requiresAuthenticationContextErrorCode

  /// There was an error confirming the Intent.
  /// Inspect the `paymentIntent.lastPaymentError` or `setupIntent.lastSetupError` property.
  @objc(STPPaymentHandlerPaymentErrorCode)
  case paymentErrorCode

  /// The provided PaymentIntent of SetupIntent client secret does not match the expected pattern for client secrets.
  /// Make sure that your server is returning the correct value and that is being passed to `STPPaymentHandler`.
  @objc(STPPaymentHandlerInvalidClientSecret)
  case invalidClientSecret
}

/// Completion block typedef for use in `STPPaymentHandler` methods for Payment Intents.
public typealias STPPaymentHandlerActionPaymentIntentCompletionBlock = (
  STPPaymentHandlerActionStatus, STPPaymentIntent?, NSError?
) -> Void
/// Completion block typedef for use in `STPPaymentHandler` methods for Setup Intents.
public typealias STPPaymentHandlerActionSetupIntentCompletionBlock = (
  STPPaymentHandlerActionStatus, STPSetupIntent?, NSError?
) -> Void

/// `STPPaymentHandler` is a utility class that confirms PaymentIntents/SetupIntents and handles any authentication required, such as 3DS1/3DS2 for Strong Customer Authentication.
/// It can present authentication UI on top of your app or redirect users out of your app (to e.g. their banking app).
/// - seealso: https://stripe.com/docs/mobile/ios/authentication
@available(iOSApplicationExtension, unavailable)
@available(macCatalystApplicationExtension, unavailable)
public class STPPaymentHandler: NSObject, SFSafariViewControllerDelegate, STPURLCallbackListener
{

  /// The error domain for errors in `STPPaymentHandler`.
  @objc public static let errorDomain = "STPPaymentHandlerErrorDomain"

  private var currentAction: STPPaymentHandlerActionParams?
  /// YES from when a public method is first called until its associated completion handler is called.
  private var inProgress = false
  private var safariViewController: SFSafariViewController?

  /// The globally shared instance of `STPPaymentHandler`.
  @objc public static let sharedHandler: STPPaymentHandler = STPPaymentHandler()

  /// The globally shared instance of `STPPaymentHandler`.
  @objc
  public class func shared() -> STPPaymentHandler {
    return STPPaymentHandler.sharedHandler
  }

  /// `STPPaymentHandler` should not be directly initialized.
  private override init() {
    self.apiClient = STPAPIClient.shared
    self.threeDSCustomizationSettings = STPThreeDSCustomizationSettings()
    super.init()
  }

  /// By default `sharedHandler` initializes with STPAPIClient.shared.
  @objc public var apiClient: STPAPIClient

  /// Customizable settings to use when performing 3DS2 authentication.
  /// Note: Configure this before calling any methods.
  /// Defaults to `STPThreeDSCustomizationSettings.defaultSettings()`.
  @objc public var threeDSCustomizationSettings: STPThreeDSCustomizationSettings

  /// Confirms the PaymentIntent with the provided parameters and handles any `nextAction` required
  /// to authenticate the PaymentIntent.
  /// Call this method if you are using automatic confirmation.  - seealso: https://stripe.com/docs/payments/payment-intents/ios
  /// - Parameters:
  ///   - paymentParams: The params used to confirm the PaymentIntent. Note that this method overrides the value of `paymentParams.useStripeSDK` to `@YES`.
  ///   - authenticationContext: The authentication context used to authenticate the payment.
  ///   - completion: The completion block. If the status returned is `STPPaymentHandlerActionStatusSucceeded`, the PaymentIntent status will always be either STPPaymentIntentStatusSucceeded or STPPaymentIntentStatusRequiresCapture if you are using manual capture. In the latter case, capture the PaymentIntent to complete the payment.
  @objc(confirmPayment:withAuthenticationContext:completion:)
  public func confirmPayment(
    _ paymentParams: STPPaymentIntentParams,
    with authenticationContext: STPAuthenticationContext,
    completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
  ) {
    if inProgress {
      completion(.failed, nil, _error(for: .noConcurrentActionsErrorCode, userInfo: nil))
      return
    } else if !STPPaymentIntentParams.isClientSecretValid(paymentParams.clientSecret) {
      completion(.failed, nil, _error(for: .invalidClientSecret, userInfo: nil))
      return
    }
    inProgress = true
    weak var weakSelf = self
    // wrappedCompletion ensures we perform some final logic before calling the completion block.
    let wrappedCompletion: STPPaymentHandlerActionPaymentIntentCompletionBlock = {
      status, paymentIntent, error in
      guard let strongSelf = weakSelf else {
        return
      }
      // Reset our internal state
      strongSelf.inProgress = false
      // Ensure the .succeeded case returns a PaymentIntent in the expected state.
      if let paymentIntent = paymentIntent,
        status == .succeeded
      {
        let successIntentState =
          paymentIntent.status == .succeeded || paymentIntent.status == .requiresCapture
          || (paymentIntent.status == .processing
            && STPPaymentHandler._isProcessingIntentSuccess(
              for: paymentIntent.paymentMethod?.type ?? .unknown) ||
            (paymentIntent.status == .requiresAction && strongSelf._isPaymentIntentNextActionVoucherBased(nextAction: paymentIntent.nextAction)))

        if error == nil && successIntentState {
          completion(.succeeded, paymentIntent, nil)
        } else {
          assert(false, "Calling completion with invalid state")
          completion(
            .failed, paymentIntent,
            error ?? strongSelf._error(for: .intentStatusErrorCode, userInfo: nil))
        }
        return
      }
      completion(status, paymentIntent, error)
    }

    let confirmCompletionBlock: STPPaymentIntentCompletionBlock = { paymentIntent, error in
      guard let strongSelf = weakSelf else {
        return
      }
      if let paymentIntent = paymentIntent,
        error == nil
      {
        strongSelf._handleNextAction(
          forPayment: paymentIntent,
          with: authenticationContext,
          returnURL: paymentParams.returnURL
        ) { status, completedPaymentIntent, completedError in
          wrappedCompletion(status, completedPaymentIntent, completedError)
        }
      } else {
        wrappedCompletion(.failed, paymentIntent, error as NSError?)
      }
    }

    var params = paymentParams
    // We always set useStripeSDK = @YES in STPPaymentHandler
    if !(params.useStripeSDK?.boolValue ?? false) {
      params = paymentParams.copy() as! STPPaymentIntentParams
      params.useStripeSDK = NSNumber(value: true)
    }
    apiClient.confirmPaymentIntent(
      with: params,
      expand: ["payment_method"],
      completion: confirmCompletionBlock)
  }

  /// :nodoc:
  @available(*, deprecated, message: "Use confirmPayment(_:with:completion:) instead", renamed: "confirmPayment(_:with:completion:)")
  public func confirmPayment(
    withParams: STPPaymentIntentParams,
    authenticationContext: STPAuthenticationContext,
    completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
  ) {
    self.confirmPayment(withParams, with: authenticationContext, completion: completion)
  }
  
  /// Handles any `nextAction` required to authenticate the PaymentIntent.
  /// Call this method if you are using manual confirmation.  - seealso: https://stripe.com/docs/payments/payment-intents/ios
  /// - Parameters:
  ///   - paymentIntentClientSecret: The client secret of the PaymentIntent to handle next actions for.
  ///   - authenticationContext: The authentication context used to authenticate the payment.
  ///   - returnURL: An optional URL to redirect your customer back to after they authenticate or cancel in a webview. This should match the returnURL you specified during PaymentIntent confirmation.
  ///   - completion: The completion block. If the status returned is `STPPaymentHandlerActionStatusSucceeded`, the PaymentIntent status will always be either STPPaymentIntentStatusSucceeded, or STPPaymentIntentStatusRequiresConfirmation, or STPPaymentIntentStatusRequiresCapture if you are using manual capture. In the latter two cases, confirm or capture the PaymentIntent on your backend to complete the payment.
  @objc(handleNextActionForPayment:withAuthenticationContext:returnURL:completion:)
  public func handleNextAction(
    forPayment paymentIntentClientSecret: String,
    with authenticationContext: STPAuthenticationContext,
    returnURL: String?,
    completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
  ) {
    if inProgress {
      assert(false, "Should not handle multiple payments at once.")
      completion(.failed, nil, _error(for: .noConcurrentActionsErrorCode, userInfo: nil))
      return
    } else if !STPPaymentIntentParams.isClientSecretValid(paymentIntentClientSecret) {
      completion(.failed, nil, _error(for: .invalidClientSecret, userInfo: nil))
      return
    }

    inProgress = true
    weak var weakSelf = self
    // wrappedCompletion ensures we perform some final logic before calling the completion block.
    let wrappedCompletion: STPPaymentHandlerActionPaymentIntentCompletionBlock = {
      status, paymentIntent, error in
      guard let strongSelf = weakSelf else {
        return
      }
      // Reset our internal state
      strongSelf.inProgress = false
      // Ensure the .succeeded case returns a PaymentIntent in the expected state.
      if let paymentIntent = paymentIntent,
        status == .succeeded
      {
        let successIntentState =
          paymentIntent.status == .succeeded ||
          paymentIntent.status == .requiresCapture ||
          paymentIntent.status == .requiresConfirmation
        
        if error == nil && successIntentState {
          completion(.succeeded, paymentIntent, nil)
        } else {
          assert(false, "Calling completion with invalid state")
          completion(
            .failed, paymentIntent,
            error ?? strongSelf._error(for: .intentStatusErrorCode, userInfo: nil))
        }
        return
      }
      completion(status, paymentIntent, error)
    }

    let retrieveCompletionBlock: STPPaymentIntentCompletionBlock = { paymentIntent, error in
      guard let strongSelf = weakSelf else {
        return
      }
      if let paymentIntent = paymentIntent,
        error == nil
      {
        if paymentIntent.status == .requiresConfirmation {
          // The caller forgot to confirm the paymentIntent on the backend before calling this method
          wrappedCompletion(
            .failed, paymentIntent,
            strongSelf._error(
              for: .intentStatusErrorCode,
              userInfo: [
                STPError.errorMessageKey:
                  "Confirm the PaymentIntent on the backend before calling handleNextActionForPayment:withAuthenticationContext:completion."
              ]))
        } else {
          strongSelf._handleNextAction(
            forPayment: paymentIntent,
            with: authenticationContext,
            returnURL: returnURL
          ) { status, completedPaymentIntent, completedError in
            wrappedCompletion(status, completedPaymentIntent, completedError)
          }
        }
      } else {
        wrappedCompletion(.failed, paymentIntent, error as NSError?)
      }
    }

    apiClient.retrievePaymentIntent(
      withClientSecret: paymentIntentClientSecret,
      expand: ["payment_method"],
      completion: retrieveCompletionBlock)
  }

  /// Confirms the SetupIntent with the provided parameters and handles any `nextAction` required
  /// to authenticate the SetupIntent.
  /// - seealso: https://stripe.com/docs/payments/cards/saving-cards#saving-card-without-payment
  /// - Parameters:
  ///   - setupIntentConfirmParams: The params used to confirm the SetupIntent. Note that this method overrides the value of `setupIntentConfirmParams.useStripeSDK` to `@YES`.
  ///   - authenticationContext: The authentication context used to authenticate the SetupIntent.
  ///   - completion: The completion block. If the status returned is `STPPaymentHandlerActionStatusSucceeded`, the SetupIntent status will always be STPSetupIntentStatusSucceeded.
  @objc(confirmSetupIntent:withAuthenticationContext:completion:)
  public func confirmSetupIntent(
    _ setupIntentConfirmParams: STPSetupIntentConfirmParams,
    with authenticationContext: STPAuthenticationContext,
    completion: @escaping STPPaymentHandlerActionSetupIntentCompletionBlock
  ) {
    if inProgress {
      assert(false, "Should not handle multiple payments at once.")
      completion(.failed, nil, _error(for: .noConcurrentActionsErrorCode, userInfo: nil))
      return
    } else if !STPSetupIntentConfirmParams.isClientSecretValid(
      setupIntentConfirmParams.clientSecret)
    {
      completion(.failed, nil, _error(for: .invalidClientSecret, userInfo: nil))
      return
    }

    inProgress = true
    weak var weakSelf = self
    // wrappedCompletion ensures we perform some final logic before calling the completion block.
    let wrappedCompletion: STPPaymentHandlerActionSetupIntentCompletionBlock = {
      status, setupIntent, error in
      guard let strongSelf = weakSelf else {
        return
      }
      // Reset our internal state
      strongSelf.inProgress = false

      if status == .succeeded {
        // Ensure the .succeeded case returns a PaymentIntent in the expected state.
        if let setupIntent = setupIntent,
          error == nil,
          setupIntent.status == .succeeded
        {
          completion(.succeeded, setupIntent, nil)
        } else {
          assert(false, "Calling completion with invalid state")
          completion(
            .failed, setupIntent,
            error ?? strongSelf._error(for: .intentStatusErrorCode, userInfo: nil))
        }

      } else {
        completion(status, setupIntent, error)
      }
    }

    let confirmCompletionBlock: STPSetupIntentCompletionBlock = { setupIntent, error in
      guard let strongSelf = weakSelf else {
        return
      }

      if let setupIntent = setupIntent,
        error == nil
      {
        let action = STPPaymentHandlerSetupIntentActionParams(
          apiClient: self.apiClient,
          authenticationContext: authenticationContext,
          threeDSCustomizationSettings: self.threeDSCustomizationSettings,
          setupIntent: setupIntent,
          returnURL: setupIntentConfirmParams.returnURL
        ) { status, resultSetupIntent, resultError in
          guard let strongSelf2 = weakSelf else {
            return
          }
          strongSelf2.currentAction = nil

          wrappedCompletion(status, resultSetupIntent, resultError)
        }
        strongSelf.currentAction = action
        let requiresAction = strongSelf._handleSetupIntentStatus(forAction: action)
        if requiresAction {
          strongSelf._handleAuthenticationForCurrentAction()
        }
      } else {
        wrappedCompletion(.failed, setupIntent, error as NSError?)
      }
    }
    var params = setupIntentConfirmParams
    if !(params.useStripeSDK?.boolValue ?? false) {
      params = setupIntentConfirmParams.copy() as! STPSetupIntentConfirmParams
      params.useStripeSDK = NSNumber(value: true)
    }
    apiClient.confirmSetupIntent(with: params, completion: confirmCompletionBlock)
  }

  /// Handles any `nextAction` required to authenticate the SetupIntent.
  /// Call this method if you are confirming the SetupIntent on your backend and get a status of requires_action.
  /// - Parameters:
  ///   - setupIntentClientSecret: The client secret of the SetupIntent to handle next actions for.
  ///   - authenticationContext: The authentication context used to authenticate the SetupIntent.
  ///   - returnURL: An optional URL to redirect your customer back to after they authenticate or cancel in a webview. This should match the returnURL you specified during SetupIntent confirmation.
  ///   - completion: The completion block. If the status returned is `STPPaymentHandlerActionStatusSucceeded`, the SetupIntent status will always be  STPSetupIntentStatusSucceeded.
  @objc(handleNextActionForSetupIntent:withAuthenticationContext:returnURL:completion:)
  public func handleNextAction(
    forSetupIntent setupIntentClientSecret: String,
    with authenticationContext: STPAuthenticationContext,
    returnURL: String?,
    completion: @escaping STPPaymentHandlerActionSetupIntentCompletionBlock
  ) {
    if inProgress {
      assert(false, "Should not handle multiple payments at once.")
      completion(.failed, nil, _error(for: .noConcurrentActionsErrorCode, userInfo: nil))
      return
    } else if !STPSetupIntentConfirmParams.isClientSecretValid(setupIntentClientSecret) {
      completion(.failed, nil, _error(for: .invalidClientSecret, userInfo: nil))
      return
    }

    inProgress = true
    weak var weakSelf = self
    // wrappedCompletion ensures we perform some final logic before calling the completion block.
    let wrappedCompletion: STPPaymentHandlerActionSetupIntentCompletionBlock = {
      status, setupIntent, error in
      guard let strongSelf = weakSelf else {
        return
      }
      // Reset our internal state
      strongSelf.inProgress = false

      if status == .succeeded {
        // Ensure the .succeeded case returns a PaymentIntent in the expected state.
        if let setupIntent = setupIntent,
          error == nil,
          setupIntent.status == .succeeded
        {
          completion(.succeeded, setupIntent, nil)
        } else {
          assert(false, "Calling completion with invalid state")
          completion(
            .failed, setupIntent,
            error ?? strongSelf._error(for: .intentStatusErrorCode, userInfo: nil))
        }

      } else {
        completion(status, setupIntent, error)
      }
    }

    let retrieveCompletionBlock: STPSetupIntentCompletionBlock = { setupIntent, error in
      guard let strongSelf = weakSelf else {
        return
      }
      if let setupIntent = setupIntent,
        error == nil
      {
        if setupIntent.status == .requiresConfirmation {
          // The caller forgot to confirm the setupIntent on the backend before calling this method
          wrappedCompletion(
            .failed, setupIntent,
            strongSelf._error(
              for: .intentStatusErrorCode,
              userInfo: [
                STPError.errorMessageKey:
                  "Confirm the SetupIntent on the backend before calling handleNextActionForSetupIntent:withAuthenticationContext:completion."
              ]))
        } else {
          strongSelf._handleNextAction(
            for: setupIntent,
            with: authenticationContext,
            returnURL: returnURL
          ) { status, completedSetupIntent, completedError in
            wrappedCompletion(status, completedSetupIntent, completedError)
          }
        }
      } else {
        wrappedCompletion(.failed, setupIntent, error as NSError?)
      }
    }

    apiClient.retrieveSetupIntent(
      withClientSecret: setupIntentClientSecret, completion: retrieveCompletionBlock)
  }

  // MARK: - Private Helpers

  /// Depending on the PaymentMethod Type, after handling next action and confirming,
  /// we should either expect a success state on the PaymentIntent, or for certain asynchronous
  /// PaymentMethods like SEPA Debit, processing is considered a completed PaymentIntent flow
  /// because the funds can take up to 14 days to transfer from the customer's bank.
  class func _isProcessingIntentSuccess(for type: STPPaymentMethodType) -> Bool {
    switch type {
    /* Asynchronous */
    case .SEPADebit,
      .bacsDebit /* Bacs Debit takes 2-3 business days */,
      .AUBECSDebit,
      .sofort:
      return true

    /* Synchronous */
    case .alipay,
      .card,
      .UPI,
      .iDEAL,
      .FPX,
      .cardPresent,
      .giropay,
      .EPS,
      .payPal,
      .przelewy24,
      .bancontact,
      .netBanking,
      .OXXO,
      .grabPay:
      return false

    case .unknown:
      return false

    @unknown default:
      return false
    }
  }

  func _handleNextAction(
    forPayment paymentIntent: STPPaymentIntent,
    with authenticationContext: STPAuthenticationContext,
    returnURL returnURLString: String?,
    completion: @escaping STPPaymentHandlerActionPaymentIntentCompletionBlock
  ) {
    guard paymentIntent.status != .requiresPaymentMethod else {
      // The caller forgot to attach a paymentMethod.
      completion(
        .failed, paymentIntent, _error(for: .requiresPaymentMethodErrorCode, userInfo: nil))
      return
    }

    weak var weakSelf = self
    let action = STPPaymentHandlerPaymentIntentActionParams(
      apiClient: apiClient,
      authenticationContext: authenticationContext,
      threeDSCustomizationSettings: threeDSCustomizationSettings,
      paymentIntent: paymentIntent,
      returnURL: returnURLString
    ) { status, resultPaymentIntent, error in
      guard let strongSelf = weakSelf else {
        return
      }
      strongSelf.currentAction = nil
      completion(status, resultPaymentIntent, error)
    }
    currentAction = action
    let requiresAction = _handlePaymentIntentStatus(forAction: action)
    if requiresAction {
      _handleAuthenticationForCurrentAction()
    }
  }

  func _handleNextAction(
    for setupIntent: STPSetupIntent,
    with authenticationContext: STPAuthenticationContext,
    returnURL returnURLString: String?,
    completion: @escaping STPPaymentHandlerActionSetupIntentCompletionBlock
  ) {
    guard setupIntent.status != .requiresPaymentMethod else {
      // The caller forgot to attach a paymentMethod.
      completion(.failed, setupIntent, _error(for: .requiresPaymentMethodErrorCode, userInfo: nil))
      return
    }

    weak var weakSelf = self
    let action = STPPaymentHandlerSetupIntentActionParams(
      apiClient: apiClient,
      authenticationContext: authenticationContext,
      threeDSCustomizationSettings: threeDSCustomizationSettings,
      setupIntent: setupIntent,
      returnURL: returnURLString
    ) { status, resultSetupIntent, resultError in
      guard let strongSelf = weakSelf else {
        return
      }
      strongSelf.currentAction = nil
      completion(status, resultSetupIntent, resultError)
    }
    currentAction = action
    let requiresAction = _handleSetupIntentStatus(forAction: action)
    if requiresAction {
      _handleAuthenticationForCurrentAction()
    }
  }

  /// Calls the current action's completion handler for the SetupIntent status, or returns YES if the status is ...RequiresAction.
  func _handleSetupIntentStatus(forAction action: STPPaymentHandlerSetupIntentActionParams) -> Bool
  {
    guard let setupIntent = action.setupIntent else {
      assert(false, "Calling _handleSetupIntentStatus without a setupIntent")
      return false
    }

    switch setupIntent.status {
    case .unknown:
      action.complete(
        with: STPPaymentHandlerActionStatus.failed,
        error: _error(
          for: .intentStatusErrorCode,
          userInfo: [
            "STPSetupIntent": setupIntent.description
          ]))

    case .requiresPaymentMethod:
      // If the user forgot to attach a PaymentMethod, they get an error before this point.
      // If confirmation fails (eg not authenticated, card declined) the SetupIntent transitions to this state.
      if let lastSetupError = setupIntent.lastSetupError {
        if lastSetupError.code == STPSetupIntentLastSetupError.CodeAuthenticationFailure {
          action.complete(
            with: STPPaymentHandlerActionStatus.failed,
            error: _error(for: .notAuthenticatedErrorCode, userInfo: nil))
        } else if lastSetupError.type == .card {
          action.complete(
            with: STPPaymentHandlerActionStatus.failed,
            error: _error(
              for: .paymentErrorCode,
              userInfo: [
                NSLocalizedDescriptionKey: lastSetupError.message ?? ""
              ]))
        } else {
          action.complete(
            with: STPPaymentHandlerActionStatus.failed,
            error: _error(for: .paymentErrorCode, userInfo: nil))
        }
      } else {
        action.complete(
          with: STPPaymentHandlerActionStatus.failed,
          error: _error(for: .paymentErrorCode, userInfo: nil))
      }

    case .requiresConfirmation:
      action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)

    case .requiresAction:
      return true

    case .processing:
      action.complete(
        with: STPPaymentHandlerActionStatus.failed,
        error: _error(for: .intentStatusErrorCode, userInfo: nil))

    case .succeeded:
      action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)

    case .canceled:
      action.complete(with: STPPaymentHandlerActionStatus.canceled, error: nil)

    @unknown default:
      fatalError()
    }
    return false
  }

  /// Calls the current action's completion handler for the PaymentIntent status, or returns YES if the status is ...RequiresAction.
  func _handlePaymentIntentStatus(forAction action: STPPaymentHandlerPaymentIntentActionParams)
    -> Bool
  {
    guard let paymentIntent = action.paymentIntent else {
      assert(false, "Calling _handlePaymentIntentStatus without a paymentIntent")
      return false
    }

    switch paymentIntent.status {
    case .unknown:
      action.complete(
        with: STPPaymentHandlerActionStatus.failed,
        error: _error(
          for: .intentStatusErrorCode,
          userInfo: [
            "STPPaymentIntent": paymentIntent.description
          ]))

    case .requiresPaymentMethod:
      // If the user forgot to attach a PaymentMethod, they get an error before this point.
      // If confirmation fails (eg not authenticated, card declined) the PaymentIntent transitions to this state.
      if let lastPaymentError = paymentIntent.lastPaymentError {
        if lastPaymentError.code == STPPaymentIntentLastPaymentError.ErrorCodeAuthenticationFailure
        {
          action.complete(
            with: STPPaymentHandlerActionStatus.failed,
            error: _error(for: .notAuthenticatedErrorCode, userInfo: nil))
        } else if lastPaymentError.type == .card {
          action.complete(
            with: STPPaymentHandlerActionStatus.failed,
            error: _error(
              for: .paymentErrorCode,
              userInfo: [
                NSLocalizedDescriptionKey: lastPaymentError.message ?? ""
              ]))
        } else {
          action.complete(
            with: STPPaymentHandlerActionStatus.failed,
            error: _error(for: .paymentErrorCode, userInfo: nil))
        }
      } else {
        action.complete(
          with: STPPaymentHandlerActionStatus.failed,
          error: _error(for: .paymentErrorCode, userInfo: nil))
      }

    case .requiresConfirmation:
      action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)

    case .requiresAction:
      return true

    case .processing:
      if let type = paymentIntent.paymentMethod?.type,
        STPPaymentHandler._isProcessingIntentSuccess(for: type)
      {
        action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)
      } else {
        action.complete(
          with: STPPaymentHandlerActionStatus.failed,
          error: _error(for: .intentStatusErrorCode, userInfo: nil))
      }

    case .succeeded:
      action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)

    case .requiresCapture:
      action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil)

    case .canceled:
      action.complete(with: STPPaymentHandlerActionStatus.canceled, error: nil)

    case .requiresSource:
      fatalError()
    case .requiresSourceAction:
      fatalError()
    }
    return false
  }

  func _handleAuthenticationForCurrentAction() {
    guard let currentAction = currentAction,
      let authenticationAction = currentAction.nextAction()
    else {
      return
    }

    switch authenticationAction.type {
    case .unknown:
      currentAction.complete(
        with: STPPaymentHandlerActionStatus.failed,
        error: _error(
          for: .unsupportedAuthenticationErrorCode,
          userInfo: [
            "STPIntentAction": authenticationAction.description
          ]))

    case .redirectToURL:
      if let redirectToURL = authenticationAction.redirectToURL {
        _handleRedirect(to: redirectToURL.url, withReturn: redirectToURL.returnURL)
      } else {
        currentAction.complete(
          with: STPPaymentHandlerActionStatus.failed,
          error: _error(
            for: .unsupportedAuthenticationErrorCode,
            userInfo: [
              "STPIntentAction": authenticationAction.description
            ]))
      }

    case .alipayHandleRedirect:
      if let alipayHandleRedirect = authenticationAction.alipayHandleRedirect {
        _handleRedirect(
          to: alipayHandleRedirect.nativeURL, fallbackURL: alipayHandleRedirect.url,
          return: alipayHandleRedirect.returnURL)
      } else {
        currentAction.complete(
          with: STPPaymentHandlerActionStatus.failed,
          error: _error(
            for: .unsupportedAuthenticationErrorCode,
            userInfo: [
              "STPIntentAction": authenticationAction.description
            ]))
      }

    case .OXXODisplayDetails:
      if let hostedVoucherURL = authenticationAction.oxxoDisplayDetails?.hostedVoucherURL {
        self._handleRedirect(to: hostedVoucherURL, withReturn: nil)
      }  else {
        currentAction.complete(
          with: STPPaymentHandlerActionStatus.failed,
          error: _error(
            for: .unsupportedAuthenticationErrorCode,
            userInfo: [
              "STPIntentAction": authenticationAction.description
            ]))
      }
      
    case .useStripeSDK:
      if let useStripeSDK = authenticationAction.useStripeSDK {
        switch useStripeSDK.type {
        case .unknown:
          currentAction.complete(
            with: STPPaymentHandlerActionStatus.failed,
            error: _error(
              for: .unsupportedAuthenticationErrorCode,
              userInfo: [
                "STPIntentAction": authenticationAction.description
              ]))

        case .threeDS2Fingerprint:
          guard let threeDSService = currentAction.threeDS2Service else {
            currentAction.complete(
              with: STPPaymentHandlerActionStatus.failed,
              error: _error(
                for: .stripe3DS2ErrorCode,
                userInfo: [
                  "description": "Failed to initialize STDSThreeDS2Service."
                ]))
            return
          }
          var transaction: STDSTransaction?
          var authRequestParams: STDSAuthenticationRequestParameters?

          STDSSwiftTryCatch.try(
            {
              transaction = threeDSService.createTransaction(
                forDirectoryServer: useStripeSDK.directoryServerID ?? "",
                serverKeyID: useStripeSDK.directoryServerKeyID,
                certificateString: useStripeSDK.directoryServerCertificate ?? "",
                rootCertificateStrings: useStripeSDK.rootCertificateStrings ?? [],
                withProtocolVersion: "2.1.0")

              authRequestParams = transaction?.createAuthenticationRequestParameters()

            },
            catch: { exception in
              currentAction.complete(
                with: STPPaymentHandlerActionStatus.failed,
                error: self._error(
                  for: .stripe3DS2ErrorCode,
                  userInfo: [
                    "exception": exception.description
                  ]))
            },
            finallyBlock: {
            })

          STPAnalyticsClient.sharedClient.log3DS2AuthenticateAttempt(
            with: currentAction.apiClient.configuration,
            intentID: currentAction.intentStripeID ?? "")

          if let authParams = authRequestParams,
            let transaction = transaction
          {
            currentAction.apiClient.authenticate3DS2(
              authParams,
              sourceIdentifier: useStripeSDK.threeDSSourceID ?? "",
              returnURL: currentAction.returnURLString,
              maxTimeout: currentAction.threeDSCustomizationSettings.authenticationTimeout
            ) { (authenticateResponse, error) in
              if let authenticateResponse = authenticateResponse,
                error == nil
              {

                if let aRes = authenticateResponse.authenticationResponse {

                  if aRes.isChallengeRequired {
                    let challengeParameters = STDSChallengeParameters(authenticationResponse: aRes)

                    let doChallenge: STPVoidBlock = {
                      var presentationError: NSError?

                      if !self._canPresent(
                        with: currentAction.authenticationContext, error: &presentationError)
                      {
                        currentAction.complete(
                          with: STPPaymentHandlerActionStatus.failed, error: presentationError)
                      } else {
                        STDSSwiftTryCatch.try(
                          {
                            transaction.doChallenge(
                              with: currentAction.authenticationContext
                                .authenticationPresentingViewController(),
                              challengeParameters: challengeParameters,
                              challengeStatusReceiver: self,
                              timeout: TimeInterval(
                                currentAction.threeDSCustomizationSettings.authenticationTimeout
                                  * 60))

                          },
                          catch: { exception in
                            self.currentAction?.complete(
                              with: STPPaymentHandlerActionStatus.failed,
                              error: self._error(
                                for: .stripe3DS2ErrorCode,
                                userInfo: [
                                  "exception": exception
                                ]))
                          },
                          finallyBlock: {
                          })
                      }
                    }

                    if currentAction.authenticationContext.responds(
                      to: #selector(STPAuthenticationContext.prepare(forPresentation:)))
                    {
                      currentAction.authenticationContext.prepare?(forPresentation: doChallenge)
                    } else {
                      doChallenge()
                    }

                  } else {
                    // Challenge not required, finish the flow.
                    transaction.close()
                    STPAnalyticsClient.sharedClient.log3DS2FrictionlessFlow(
                      with: currentAction.apiClient.configuration,
                      intentID: currentAction.intentStripeID ?? "")

                    self._retrieveAndCheckIntentForCurrentAction()
                  }

                } else if let fallbackURL = authenticateResponse.fallbackURL {
                  self._handleRedirect(
                    to: fallbackURL, withReturn: URL(string: currentAction.returnURLString ?? ""))
                } else {
                  currentAction.complete(
                    with: STPPaymentHandlerActionStatus.failed,
                    error: self._error(
                      for: .unsupportedAuthenticationErrorCode,
                      userInfo: [
                        "STPIntentAction": authenticationAction.description
                      ]))
                }

              } else {
                currentAction.complete(
                  with: STPPaymentHandlerActionStatus.failed, error: (error as NSError?))
              }
            }

          } else {
            currentAction.complete(
              with: STPPaymentHandlerActionStatus.failed,
              error: self._error(
                for: .unsupportedAuthenticationErrorCode,
                userInfo: [
                  "STPIntentAction": authenticationAction.description
                ]))
          }

        case .threeDS2Redirect:
          if let redirectURL = useStripeSDK.redirectURL {
            let returnURL: URL?
            if let returnURLString = currentAction.returnURLString {
              returnURL = URL(string: returnURLString)
            } else {
              returnURL = nil
            }
            _handleRedirect(to: redirectURL, withReturn: returnURL)
          } else {
            // TOOD : Error
          }

        @unknown default:
          fatalError()
        }
      } else {
        currentAction.complete(
          with: STPPaymentHandlerActionStatus.failed,
          error: _error(
            for: .unsupportedAuthenticationErrorCode,
            userInfo: [
              "STPIntentAction": authenticationAction.description
            ]))
      }

    @unknown default:
      fatalError()
    }
  }

  func _retrieveAndCheckIntentForCurrentAction() {
    // Alipay requires us to hit an endpoint before retrieving the PI, to ensure the status is up to date.
    let pingMarlinIfNecessary:
      ((STPPaymentHandlerPaymentIntentActionParams, @escaping STPVoidBlock) -> Void) = {
        currentAction, completionBlock in
        if let paymentMethod = currentAction.paymentIntent?.paymentMethod,
          paymentMethod.type == .alipay,
          let alipayHandleRedirect = currentAction.nextAction()?.alipayHandleRedirect,
          let alipayReturnURL = alipayHandleRedirect.marlinReturnURL
        {

          // Make a request to the return URL
          let request: URLRequest = URLRequest(url: alipayReturnURL)
          let task: URLSessionDataTask = URLSession.shared.dataTask(
            with: request,
            completionHandler: { _, _, _ in
              completionBlock()
            })
          task.resume()
        } else {
          completionBlock()
        }
      }

    if let currentAction = self.currentAction as? STPPaymentHandlerPaymentIntentActionParams,
      let paymentIntent = currentAction.paymentIntent
    {

      pingMarlinIfNecessary(
        currentAction,
        {
          currentAction.apiClient.retrievePaymentIntent(
            withClientSecret: paymentIntent.clientSecret,
            expand: ["payment_method"]
          ) { retrievedPaymentIntent, error in
            currentAction.paymentIntent = retrievedPaymentIntent
            if let error = error {
              currentAction.complete(
                with: STPPaymentHandlerActionStatus.failed, error: error as NSError?)
            } else {
              let requiresAction: Bool = self._handlePaymentIntentStatus(forAction: currentAction)
              if requiresAction {
                // If the status is still RequiresAction, the user exited from the redirect before the
                // payment intent was updated. Consider it a cancel
                self._markChallengeCanceled(withCompletion: { _, _ in
                  // We don't forward cancelation errors
                  currentAction.complete(with: STPPaymentHandlerActionStatus.canceled, error: nil)
                })
              }
            }
          }
        })
    } else if let currentAction = self.currentAction as? STPPaymentHandlerSetupIntentActionParams,
      let setupIntent = currentAction.setupIntent
    {

      currentAction.apiClient.retrieveSetupIntent(
        withClientSecret: setupIntent.clientSecret
      ) { retrievedSetupIntent, error in
        currentAction.setupIntent = retrievedSetupIntent
        if let error = error {
          currentAction.complete(
            with: STPPaymentHandlerActionStatus.failed, error: error as NSError?)
        } else {
          let requiresAction: Bool = self._handleSetupIntentStatus(forAction: currentAction)

          if requiresAction {
            // If the status is still RequiresAction, the user exited from the redirect before the
            // setup intent was updated. Consider it a cancel
            self._markChallengeCanceled(withCompletion: { _, _ in
              // We don't forward cancelation errors
              currentAction.complete(with: STPPaymentHandlerActionStatus.canceled, error: nil)
            })
          }
        }

      }
    } else {
      assert(false, "currentAction is an unknown type or nil intent.")
    }
  }

  @objc func _handleWillForegroundNotification() {
    NotificationCenter.default.removeObserver(
      self, name: UIApplication.willEnterForegroundNotification, object: nil)
    _retrieveAndCheckIntentForCurrentAction()
  }

  func _handleRedirect(to url: URL, withReturn returnURL: URL?) {
    _handleRedirect(to: url, fallbackURL: url, return: returnURL)
  }

  /// This method:
  /// 1. Redirects to an app using url
  /// 2. Open fallbackURL in a webview if 1) fails
  func _handleRedirect(to url: URL?, fallbackURL: URL, return returnURL: URL?) {
    guard let currentAction = currentAction else {
      assert(false, "Calling _handleRedirect without a currentAction")
      return
    }

    if let returnURL = returnURL {
      STPURLCallbackHandler.shared().register(self, for: returnURL)
    }

    STPAnalyticsClient.sharedClient.logURLRedirectNextAction(
      with: currentAction.apiClient.configuration, intentID: currentAction.intentStripeID ?? "")

    // Open the link in SafariVC
    let presentSFViewControllerBlock: (() -> Void) = {
      let context = currentAction.authenticationContext

      let presentingViewController = context.authenticationPresentingViewController()

      let doChallenge: STPVoidBlock = {
        var presentationError: NSError?
        guard self._canPresent(with: context, error: &presentationError) else {
          currentAction.complete(
            with: STPPaymentHandlerActionStatus.failed, error: presentationError)
          return
        }

        let safariViewController = SFSafariViewController(url: fallbackURL)
        safariViewController.dismissButtonStyle = .close
        if context.responds(
          to: #selector(STPAuthenticationContext.configureSafariViewController(_:)))
        {
          context.configureSafariViewController?(safariViewController)
        }
        safariViewController.delegate = self
        self.safariViewController = safariViewController
        presentingViewController.present(safariViewController, animated: true)
      }
      if context.responds(to: #selector(STPAuthenticationContext.prepare(forPresentation:))) {
        context.prepare?(forPresentation: doChallenge)
      } else {
        doChallenge()
      }
    }

    // Redirect to an app
    // We don't want universal links to open up Safari, but we do want to allow custom URL schemes
    var options: [UIApplication.OpenExternalURLOptionsKey: Any] = [:]
    if let scheme = url?.scheme, scheme == "http" || scheme == "https" {
      options[UIApplication.OpenExternalURLOptionsKey.universalLinksOnly] = true
    }

    // We don't check canOpenURL before opening the URL because that requires users to pre-register the custom URL schemes
    if let url = url {
      UIApplication.shared.open(
        url,
        options: options,
        completionHandler: { success in
          if !success {
            // no app installed, launch safari view controller
            presentSFViewControllerBlock()
          } else {
            NotificationCenter.default.addObserver(
              self,
              selector: #selector(self._handleWillForegroundNotification),
              name: UIApplication.willEnterForegroundNotification,
              object: nil)
          }
        })
    } else {
      presentSFViewControllerBlock()
    }
  }

  /// Checks if authenticationContext.authenticationPresentingViewController can be presented on.
  /// @note Call this method after `prepareAuthenticationContextForPresentation:`
  func _canPresent(with authenticationContext: STPAuthenticationContext, error: inout NSError?)
    -> Bool
  {
    // Always allow in tests:
    if NSClassFromString("XCTest") != nil {
      return true
    }

    let presentingViewController = authenticationContext.authenticationPresentingViewController()
    var canPresent = true
    var errorMessage: String?

    // Is it in the window hierarchy?
    if presentingViewController.viewIfLoaded?.window == nil {
      canPresent = false
      errorMessage =
        "authenticationPresentingViewController is not in the window hierarchy. You should probably return the top-most view controller instead."
    }

    // Is it the Apple Pay VC?
    if presentingViewController is PKPaymentAuthorizationViewController {
      // Trying to present over the Apple Pay sheet silently fails. Authentication should never happen if you're paying with Apple Pay.
      canPresent = false
      errorMessage =
        "authenticationPresentingViewController is a PKPaymentAuthorizationViewController, which cannot be presented over."
    }

    // Is it already presenting something?
    if presentingViewController.presentedViewController != nil {
      canPresent = false
      errorMessage =
        "authenticationPresentingViewController is already presenting. You should probably dismiss the presented view controller in `prepareAuthenticationContextForPresentation`."
    }

    if !canPresent && error != nil {
      error = _error(
        for: .requiresAuthenticationContextErrorCode,
        userInfo: errorMessage != nil
          ? [
            STPError.errorMessageKey: errorMessage ?? ""
          ] : nil)
    }
    return canPresent
  }
  
  /// Check paymentIntent.nextAction is voucher-based payment method.
  /// Currently only OXXO payment is voucher-based.
  /// If it's voucher-based, the paymentIntent status stays in requiresAction until the voucher is paid or expired.
  func _isPaymentIntentNextActionVoucherBased(nextAction: STPIntentAction?) -> Bool {
    if let nextAction = nextAction {
      return nextAction.type == .OXXODisplayDetails
    }
    return false
  }

  // MARK: - SFSafariViewControllerDelegate
  /// :nodoc:
  @objc
  public func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
    let context = currentAction?.authenticationContext
    if context?.responds(
      to: #selector(STPAuthenticationContext.authenticationContextWillDismiss(_:))) ?? false
    {
      context?.authenticationContextWillDismiss?(controller)
    }
    safariViewController = nil
    STPURLCallbackHandler.shared().unregisterListener(self)
    _retrieveAndCheckIntentForCurrentAction()
  }

  // MARK: - STPURLCallbackListener
  func handleURLCallback(_ url: URL) -> Bool {
    let context = currentAction?.authenticationContext
    if context?.responds(
      to: #selector(STPAuthenticationContext.authenticationContextWillDismiss(_:))) ?? false,
      let safariViewController = safariViewController
    {
      context?.authenticationContextWillDismiss?(safariViewController)
    }

    NotificationCenter.default.removeObserver(
      self, name: UIApplication.willEnterForegroundNotification, object: nil)
    STPURLCallbackHandler.shared().unregisterListener(self)
    safariViewController?.dismiss(animated: true) {
      self.safariViewController = nil
    }
    _retrieveAndCheckIntentForCurrentAction()
    return true
  }

  // This is only called after web-redirects because native 3DS2 cancels go directly
  // to the ACS
  func _markChallengeCanceled(withCompletion completion: @escaping STPBooleanSuccessBlock) {
    guard let currentAction = currentAction,
      let nextAction = currentAction.nextAction()
    else {
      assert(false, "Calling _markChallengeCanceled without currentAction or nextAction.")
      return
    }

    var threeDSSourceID: String?
    switch nextAction.type {
    case .redirectToURL:
      threeDSSourceID = nextAction.redirectToURL?.threeDSSourceID
    case .useStripeSDK:
      threeDSSourceID = nextAction.useStripeSDK?.threeDSSourceID
    case .OXXODisplayDetails, .alipayHandleRedirect, .unknown:
      break
    @unknown default:
      fatalError()
    }

    guard let cancelSourceID = threeDSSourceID else {
      // If there's no threeDSSourceID, there's nothing for us to cancel
      completion(true, nil)
      return
    }

    if let currentAction = self.currentAction as? STPPaymentHandlerPaymentIntentActionParams,
      let paymentIntent = currentAction.paymentIntent
    {

      currentAction.apiClient.cancel3DSAuthentication(
        forPaymentIntent: paymentIntent.stripeId,
        withSource: cancelSourceID
      ) { retrievedPaymentIntent, error in
        currentAction.paymentIntent = retrievedPaymentIntent
        completion(retrievedPaymentIntent != nil, error)
      }
    } else if let currentAction = self.currentAction as? STPPaymentHandlerSetupIntentActionParams,
      let setupIntent = currentAction.setupIntent
    {

      currentAction.apiClient.cancel3DSAuthentication(
        forSetupIntent: setupIntent.stripeID,
        withSource: cancelSourceID
      ) { retrievedSetupIntent, error in
        currentAction.setupIntent = retrievedSetupIntent
        completion(retrievedSetupIntent != nil, error)
      }
    } else {
      assert(false, "currentAction is an unknown type or nil intent.")
    }
  }

  func _markChallengeCompleted(withCompletion completion: @escaping STPBooleanSuccessBlock) {
    guard let currentAction = currentAction,
      let threeDSSourceID = currentAction.nextAction()?.useStripeSDK?.threeDSSourceID
    else {
      completion(false, nil)
      return
    }

    currentAction.apiClient.complete3DS2Authentication(forSource: threeDSSourceID) {
      success, error in
      if success {
        if let paymentIntentAction = currentAction as? STPPaymentHandlerPaymentIntentActionParams,
          let paymentIntent = paymentIntentAction.paymentIntent
        {

          currentAction.apiClient.retrievePaymentIntent(
            withClientSecret: paymentIntent.clientSecret,
            expand: ["payment_method"]
          ) { retrievedPaymentIntent, retrieveError in
            paymentIntentAction.paymentIntent = retrievedPaymentIntent
            completion(retrievedPaymentIntent != nil, retrieveError)
          }
        } else if let setupIntentAction = currentAction
          as? STPPaymentHandlerSetupIntentActionParams,
          let setupIntent = setupIntentAction.setupIntent
        {
          currentAction.apiClient.retrieveSetupIntent(
            withClientSecret: setupIntent.clientSecret
          ) { retrievedSetupIntent, retrieveError in
            setupIntentAction.setupIntent = retrievedSetupIntent
            completion(retrievedSetupIntent != nil, retrieveError)
          }
        } else {
          assert(false, "currentAction is an unknown type or nil intent.")
        }
      } else {
        completion(success, error)
      }
    }

  }

  // MARK: - Errors
  func _error(
    for errorCode: STPPaymentHandlerErrorCode, userInfo additionalUserInfo: [AnyHashable: Any]?
  ) -> NSError {
    var userInfo: [AnyHashable: Any] = additionalUserInfo ?? [:]
    switch errorCode {
    // 3DS(2) flow expected user errors
    case .notAuthenticatedErrorCode:
      userInfo[NSLocalizedDescriptionKey] = STPLocalizedString(
        "We are unable to authenticate your payment method. Please choose a different payment method and try again.",
        "Error when 3DS2 authentication failed (e.g. customer entered the wrong code)")

    case .timedOutErrorCode:
      userInfo[NSLocalizedDescriptionKey] = STPLocalizedString(
        "Timed out authenticating your payment method -- try again",
        "Error when 3DS2 authentication timed out.")

    // PaymentIntent has an unexpected/unknown status
    case .intentStatusErrorCode:
      // The PI's status is processing or unknown
      userInfo[STPError.errorMessageKey] =
        userInfo[STPError.errorMessageKey] ?? "The PaymentIntent status cannot be handled."
      userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

    case .unsupportedAuthenticationErrorCode:
      userInfo[STPError.errorMessageKey] =
        userInfo[STPError.errorMessageKey]
        ?? "The SDK doesn't recognize the PaymentIntent action type."
      userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

    // Programming errors
    case .requiresPaymentMethodErrorCode:
      userInfo[STPError.errorMessageKey] =
        userInfo[STPError.errorMessageKey]
        ?? "The PaymentIntent requires a PaymentMethod or Source to be attached before using STPPaymentHandler."
      userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

    case .noConcurrentActionsErrorCode:
      userInfo[STPError.errorMessageKey] =
        userInfo[STPError.errorMessageKey]
        ?? "The current action is not yet completed. STPPaymentHandler does not support concurrent calls to its API."
      userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

    case .requiresAuthenticationContextErrorCode:
      userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

    // Exceptions thrown from the Stripe3DS2 SDK. Other errors are reported via STPChallengeStatusReceiver.
    case .stripe3DS2ErrorCode:
      userInfo[STPError.errorMessageKey] =
        userInfo[STPError.errorMessageKey] ?? "There was an error in the Stripe3DS2 SDK."
      userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

    // Confirmation errors (eg card was declined)
    case .paymentErrorCode:
      userInfo[STPError.errorMessageKey] =
        userInfo[STPError.errorMessageKey]
        ?? "There was an error confirming the Intent. Inspect the `paymentIntent.lastPaymentError` or `setupIntent.lastSetupError` property."
      userInfo[NSLocalizedDescriptionKey] =
        userInfo[NSLocalizedDescriptionKey] ?? NSError.stp_unexpectedErrorMessage()

    // Client secret format error
    case .invalidClientSecret:
      userInfo[STPError.errorMessageKey] =
        userInfo[STPError.errorMessageKey]
        ?? "The provided Intent client secret does not match the expected client secret format. Make sure your server is returning the correct value and that is passed to `STPPaymentHandler`."
      userInfo[NSLocalizedDescriptionKey] =
        userInfo[NSLocalizedDescriptionKey] ?? NSError.stp_unexpectedErrorMessage()
    }
    return NSError(
      domain: STPPaymentHandler.errorDomain, code: errorCode.rawValue,
      userInfo: userInfo as? [String: Any])
  }
}

@available(iOSApplicationExtension, unavailable)
@available(macCatalystApplicationExtension, unavailable)
private extension STPPaymentHandler {
  // MARK: - STPChallengeStatusReceiver
  /// :nodoc:
  @objc(transaction:didCompleteChallengeWithCompletionEvent:)
  dynamic func transaction(
    _ transaction: STDSTransaction, didCompleteChallengeWith completionEvent: STDSCompletionEvent
  ) {
    guard let currentAction = currentAction else {
      assert(false, "Calling didCompleteChallengeWith without currentAction.")
      return
    }
    let transactionStatus = completionEvent.transactionStatus
    STPAnalyticsClient.sharedClient.log3DS2ChallengeFlowCompleted(
      with: currentAction.apiClient.configuration,
      intentID: currentAction.intentStripeID ?? "",
      uiType: transaction.presentedChallengeUIType)
    if transactionStatus == "Y" {
      _markChallengeCompleted(withCompletion: { markedCompleted, error in
        currentAction.complete(
          with: markedCompleted ? .succeeded : .failed, error: error as NSError?)
      })
    } else {
      // going to ignore the rest of the status types because they provide more detail than we require
      _markChallengeCompleted(withCompletion: { _, error in
        currentAction.complete(
          with: STPPaymentHandlerActionStatus.failed,
          error: self._error(
            for: .notAuthenticatedErrorCode,
            userInfo: [
              "transaction_status": transactionStatus
            ]))
      })
    }
  }

  /// :nodoc:
  @objc(transactionDidCancel:)
  dynamic func transactionDidCancel(_ transaction: STDSTransaction) {
    guard let currentAction = currentAction else {
      assert(false, "Calling transactionDidCancel without currentAction.")
      return
    }

    STPAnalyticsClient.sharedClient.log3DS2ChallengeFlowUserCanceled(
      with: currentAction.apiClient.configuration,
      intentID: currentAction.intentStripeID ?? "",
      uiType: transaction.presentedChallengeUIType)
    _markChallengeCompleted(withCompletion: { _, error in
      // we don't forward cancelation errors
      currentAction.complete(with: STPPaymentHandlerActionStatus.canceled, error: nil)
    })
  }

  /// :nodoc:
  @objc(transactionDidTimeOut:)
  dynamic func transactionDidTimeOut(_ transaction: STDSTransaction) {
    guard let currentAction = currentAction else {
      assert(false, "Calling transactionDidTimeOut without currentAction.")
      return
    }

    STPAnalyticsClient.sharedClient.log3DS2ChallengeFlowTimedOut(
      with: currentAction.apiClient.configuration,
      intentID: currentAction.intentStripeID ?? "",
      uiType: transaction.presentedChallengeUIType)
    _markChallengeCompleted(withCompletion: { _, error in
      currentAction.complete(
        with: STPPaymentHandlerActionStatus.failed,
        error: self._error(for: .timedOutErrorCode, userInfo: nil))
    })

  }

  /// :nodoc:
  @objc(transaction:didErrorWithProtocolErrorEvent:)
  dynamic func transaction(
    _ transaction: STDSTransaction, didErrorWith protocolErrorEvent: STDSProtocolErrorEvent
  ) {

    guard let currentAction = currentAction else {
      assert(false, "Calling didErrorWith protocolErrorEvent without currentAction.")
      return
    }

    _markChallengeCompleted(withCompletion: { _, error in
      // Add localizedError to the 3DS2 SDK error
      let threeDSError = protocolErrorEvent.errorMessage.nsErrorValue() as NSError
      var userInfo = threeDSError.userInfo
      userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

      let localizedError = NSError(
        domain: threeDSError.domain, code: threeDSError.code, userInfo: userInfo)
      STPAnalyticsClient.sharedClient.log3DS2ChallengeFlowErrored(
        with: currentAction.apiClient.configuration,
        intentID: currentAction.intentStripeID ?? "",
        errorDictionary: [
          "domain": threeDSError.domain,
          "code": NSNumber(value: threeDSError.code),
          "user_info": userInfo,
        ])
      currentAction.complete(with: STPPaymentHandlerActionStatus.failed, error: localizedError)
    })
  }

  /// :nodoc:
  @objc(transaction:didErrorWithRuntimeErrorEvent:)
  dynamic func transaction(
    _ transaction: STDSTransaction, didErrorWith runtimeErrorEvent: STDSRuntimeErrorEvent
  ) {

    guard let currentAction = currentAction else {
      assert(false, "Calling didErrorWith runtimeErrorEvent without currentAction.")
      return
    }

    _markChallengeCompleted(withCompletion: { _, error in
      // Add localizedError to the 3DS2 SDK error
      let threeDSError = runtimeErrorEvent.nsErrorValue() as NSError
      var userInfo = threeDSError.userInfo
      userInfo[NSLocalizedDescriptionKey] = NSError.stp_unexpectedErrorMessage()

      let localizedError = NSError(
        domain: threeDSError.domain, code: threeDSError.code, userInfo: userInfo)

      STPAnalyticsClient.sharedClient.log3DS2ChallengeFlowErrored(
        with: currentAction.apiClient.configuration,
        intentID: currentAction.intentStripeID ?? "",
        errorDictionary: [
          "domain": threeDSError.domain,
          "code": NSNumber(value: threeDSError.code),
          "user_info": userInfo,
        ])
      currentAction.complete(with: STPPaymentHandlerActionStatus.failed, error: localizedError)
    })
  }

  /// :nodoc:
  @objc(transactionDidPresentChallengeScreen:)
  dynamic func transactionDidPresentChallengeScreen(_ transaction: STDSTransaction) {

    guard let currentAction = currentAction else {
      assert(false, "Calling didErrorWith runtimeErrorEvent without currentAction.")
      return
    }

    STPAnalyticsClient.sharedClient.log3DS2ChallengeFlowPresented(
      with: currentAction.apiClient.configuration,
      intentID: currentAction.intentStripeID ?? "",
      uiType: transaction.presentedChallengeUIType)
  }
}
